package org.io4j.filesystem;

import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;

import org.io4j.util.Argument;

/**
 * A hierarchical path denoting a location of a file system object in the file
 * system.
 * <p>
 * Path name elements are separated by system-dependent path separator which is
 * available in {@link #SEPARATOR} and {@link #SEPARATOR_CHAR} constants. Upon
 * construction path separators are automatically converted into default
 * system-dependent path separator.
 * 
 * @see File
 */
public class Path implements Serializable, Comparable<Path> {

	private static final long serialVersionUID = -4506401717978815955L;

	/**
	 * Default system-dependent path separator, initialised from
	 * {@code file.separator} system property. Contains a single character,
	 * namely {@link #SEPARATOR_CHAR}.
	 */
	public static final String SEPARATOR = File.separator;

	/**
	 * Default system-dependent path separator, initialised from
	 * {@code file.separator} system property.
	 */
	public static final char SEPARATOR_CHAR = File.separatorChar;

	private static final char WINDOWS_SEPARATOR_CHAR = '\\';
	private static final char UNIX_SEPARATOR_CHAR = '/';

	private final File path;

	/**
	 * Constructs a path from {@code String} representation.
	 * <p>
	 * Path separators are automatically converted into default system-dependent
	 * path separator.
	 * 
	 * @throws IllegalArgumentException
	 *            if path is {@code null}
	 */
	public Path(String path) {
		Argument.isNotNull(path, "Path is null");
		this.path = new File(convertSeparators(path));
	}

	private String convertSeparators(String path) {
		String pathToReturn = path;
		if (pathToReturn.indexOf(UNIX_SEPARATOR_CHAR) != -1) {
			pathToReturn = path.replace(UNIX_SEPARATOR_CHAR, SEPARATOR_CHAR);
		}
		if (pathToReturn.indexOf(WINDOWS_SEPARATOR_CHAR) != -1) {
			pathToReturn = path.replace(WINDOWS_SEPARATOR_CHAR, SEPARATOR_CHAR);
		}
		return pathToReturn;
	}

	/**
	 * Constructs a path from {@code java.io.File} representation.
	 * <p>
	 * Path separators are automatically converted into default system-dependent
	 * path separator.
	 * 
	 * @throws IllegalArgumentException
	 *            if path is {@code null}
	 * 
	 * @see File
	 */
	public Path(File path) {
		Argument.isNotNull(path, "Path is null");
		this.path = convertSeparators(path);
	}

	private File convertSeparators(File path) {
		String pathString = path.toString();
		boolean shouldReplacePath = false;
		if (pathString.indexOf(UNIX_SEPARATOR_CHAR) != -1) {
			pathString = pathString.replace(UNIX_SEPARATOR_CHAR, SEPARATOR_CHAR);
			shouldReplacePath = true;
		}
		if (pathString.indexOf(WINDOWS_SEPARATOR_CHAR) != -1) {
			pathString = pathString.replace(WINDOWS_SEPARATOR_CHAR, SEPARATOR_CHAR);
			shouldReplacePath = true;
		}
		if (shouldReplacePath) {
			return new File(pathString);
		}
		return path;
	}

	/**
	 * Constructs a path from {@code URL} representation.
	 * <p>
	 * Path separators are automatically converted into default system-dependent
	 * path separator.
	 * 
	 * @throws IllegalArgumentException
	 *            if URL is {@code null}, protocol is not <em>file</em>, URL has
	 *            authority, reference or query component, or path component is
	 *            empty
	 * @see URL
	 */
	public Path(URL url) {
		Argument.isNotNull(url, "URL is null");
		Argument.is("file".equalsIgnoreCase(url.getProtocol()), "URL protocol is not 'file'");
		Argument.isNull(url.getAuthority(), "URL has an authority component");
		Argument.isNull(url.getRef(), "URL has a reference component");
		Argument.isNull(url.getQuery(), "URL has a query component");
		String pathFromUrl = url.getPath();
		Argument.isNot("".equals(pathFromUrl), "URL has empty path component");
		this.path = new File(convertSeparators(pathFromUrl));
	}

	/**
	 * Constructs a path from {@code URI} representation.
	 * <p>
	 * Path separators are automatically converted into default system-dependent
	 * path separator.
	 * 
	 * @throws IllegalArgumentException
	 *            if URI is {@code null}, URI is not absolute, URI is opaque,
	 *            URI's scheme is not <em>file</em>, URI has authority, fragment
	 *            or query component, or path component is empty
	 * 
	 * @see URI
	 */
	public Path(URI uri) {
		Argument.isNotNull(uri, "URI is null");
		Argument.is(uri.isAbsolute(), "URI is not absolute");
		Argument.isNot(uri.isOpaque(), "URI is opaque");
		Argument.is("file".equalsIgnoreCase(uri.getScheme()), "URI scheme is not 'file'");
		Argument.isNull(uri.getAuthority(), "URI has an authority component");
		Argument.isNull(uri.getFragment(), "URI has a fragment component");
		Argument.isNull(uri.getQuery(), "URI has a query component");
		String pathFromUri = uri.getPath();
		Argument.isNot("".equals(pathFromUri), "URI has empty path component");
		this.path = new File(convertSeparators(pathFromUri));
	}

	/**
	 * Constructs a path from the parent path and one or more name elements using
	 * default system-dependent path separator. Path separators in name elements
	 * (if present) are automatically converted into default system-dependent
	 * path separator. Examples:
	 * 
	 * <pre>
	 * Path childPath = new Path(parentPath, &quot;childdir1&quot;, &quot;childdir2&quot;);
	 * </pre>
	 * 
	 * @throws IllegalArgumentException
	 *            if parent path is {@code null}
	 */
	public Path(Path parentPath, String... names) {
		Argument.isNotNull(parentPath, "Parent path is null");
		this.path = new File(parentPath.toFile(), buildPath(names));
	}

	private String buildPath(String[] names) {
		Argument.is(names.length > 0, "No child names specified");

		StringBuilder pathString = new StringBuilder();
		for (String name : names) {
			if (!name.isEmpty()) {
				if (pathString.length() > 0) {
					pathString.append(Path.SEPARATOR);
				}
				pathString.append(convertSeparators(name));
			}
		}
		return pathString.toString();
	}

	/**
	 * Constructs a path from the parent path and one or more name elements using
	 * default system-dependent path separator. Path separators in parent path
	 * and name elements (if present) are automatically converted into default
	 * system-dependent path separator. Example:
	 * 
	 * <pre>
	 * Path childPath = new Path(&quot;dir1/dir2&quot;, &quot;childdir1&quot;, &quot;childdir2&quot;);
	 * </pre>
	 * 
	 * @throws IllegalArgumentException
	 *            if parent path is {@code null}
	 */
	public Path(String parentPath, String... names) {
		Argument.isNotNull(parentPath, "Parent path is null");
		this.path = new File(convertSeparators(parentPath), buildPath(names));
	}

	/**
	 * Tests whether the path exists in the file system.
	 * <p>
	 * To quickly test whether the path exists (without instantiating
	 * {@code Path} object) use the following:
	 * 
	 * <pre>
	 * boolean exists = Path.exists(java.io.File path)
	 * boolean exists = Path.exists(String path)
	 * </pre>
	 * 
	 * @return {@code true} if the path exists; {@code false} otherwise
	 */
	public boolean exists() {
		return path.exists();
	}

	/**
	 * Tests whether the path exists in the file system without instantiating
	 * {@code Path} object.
	 * 
	 * @param path
	 *           A path to test as {@code java.io.File}
	 * @return {@code true} if the path exists; {@code false} otherwise
	 * @throws IllegalArgumentException
	 *            is path is {@code null} or empty
	 */
	public static boolean exists(File path) {
		Argument.isNotNull(path, "Path is null");
		Argument.isNot(path.toString().isEmpty(), "Path is empty");
		return path.exists();
	}

	/**
	 * Tests whether the path exists in the file system without instantiating
	 * {@code Path} object.
	 * 
	 * @param path
	 *           A path to test as {@code String}
	 * @return {@code true} if the path exists; {@code false} otherwise
	 * @throws IllegalArgumentException
	 *            is path is {@code null} or empty
	 */
	public static boolean exists(String path) {
		Argument.isNotEmpty(path, "Path is null or empty");
		return exists(new File(path));
	}

	/**
	 * Returns a path with its name replaced with a given string.
	 * 
	 * @param name
	 *           A new name for the path
	 * @return A renamed {@code Path}
	 * @throws IllegalArgumentException
	 *            if name is {@code null} or empty
	 * @see Path#getName()
	 */
	public Path rename(String name) {
		Argument.isNotEmpty(name, "Name is null or empty");
		if (getName().equals(name)) {
			return this;
		}
		return new Path(getParent(), name);
	}

	/**
	 * Returns the name of this path (last name element in the path).
	 * 
	 * @return Name of this path or {@code ""} if path doesn't have any elements
	 */
	public String getName() {
		if (isEmpty()) {
			return "";
		}
		String[] names = toString().split(Path.SEPARATOR);
		if (names.length > 0) {
			return names[names.length - 1];
		} else {
			return "";
		}
	}

	/**
	 * Returns a last name element of this path.
	 * 
	 * @param index
	 *           the index of the name element, starting from closest element to
	 *           the root
	 * @return the name element or {@code ""} if path is empty
	 * @throws IllegalArgumentException
	 *            if {@code index} is not greater than zero, {@code index} is
	 *            greater than or equals to the number of path elements
	 */
	public String getName(int index) {
		Argument.isNot(index < 0, "Index has to be greater than zero (is " + index + ")");
		if (isEmpty()) {
			return "";
		}

		String[] names = toString().split(Path.SEPARATOR);
		if (index < names.length) {
			return names[index];
		} else {
			throw new IllegalArgumentException("Index is greater than number of name elements (is "
					+ index + ", has to be less than " + names.length + ")");
		}
	}

	boolean isEmpty() {
		return path.getPath().isEmpty();
	}

	/**
	 * Tests whether this path denotes the location of an <em>existing</em>
	 * directory in the file system.
	 * 
	 * @return {@code true} if the path denotes the location of an
	 *         <em>existing</em> directory; {@code false} otherwise
	 */
	public boolean isDirectory() {
		return path.isDirectory();
	}

	/**
	 * Tests whether this path denotes the location of an <em>existing</em> file
	 * in the file system.
	 * 
	 * @return {@code true} if the path denotes the location of an
	 *         <em>existing</em> file; {@code false} otherwise
	 */
	public boolean isFile() {
		return path.isFile();
	}

	/**
	 * Tests whether this path is absolute. See {@link java.io.File#isAbsolute()}
	 * for the definition of absolute path.
	 * 
	 * @return {@code true} if the path is absolute; {@code false} otherwise
	 */
	public boolean isAbsolute() {
		return path.isAbsolute();
	}

	/**
	 * Returns the absolute form of this path. See
	 * {@link java.io.File#isAbsolute()} for the definition of absolute path.
	 */
	public Path getAbsolute() {
		if (isAbsolute()) {
			return this;
		} else {
			return new Path(path.getAbsoluteFile());
		}
	}

	/**
	 * Returns the child path. Child path contains all name elements of the
	 * current path as well as the name provided.
	 * 
	 * @throws IllegalArgumentException
	 *            if name is {@code null} or empty
	 */
	Path createChild(String name) {
		if (name == null || name.isEmpty()) {
			throw new IllegalArgumentException("Name is null or empty");
		}
		return new Path(this, name);
	}

	/**
	 * Tests if this path has a parent. Parent path contains all name elements
	 * apart from the last one (path's name).
	 * 
	 * @return {@code true} if this path has a parent, {@code false} otherwise
	 */
	public boolean hasParent() {
		return path.getParent() != null;
	}

	/**
	 * Returns a parent path for this path. Parent path contains all name
	 * elements apart from the last one (path's name).
	 * 
	 * @return Parent path if path does have a parent
	 * @throws IllegalStateException
	 *            if this path hasn't got a parent
	 */
	public Path getParent() {
		File parentPathAsFile = path.getParentFile();
		if (parentPathAsFile != null) {
			return new Path(parentPathAsFile);
		}
		throw new IllegalStateException(
				"This path hasn't got a parent. Use 'Path.hasParent()' to test if the path has got a parent before calling this method.");
	}

	/**
	 * Returns the root path of this path (only if it's already an absolute
	 * path).
	 * 
	 * @return Root path if path is absolute
	 * @throws IllegalStateException
	 *            if path is not absolute
	 */
	public Path getRoot() {
		if (isAbsolute()) {
			Path currentPath = this;
			while (currentPath.hasParent()) {
				currentPath = currentPath.getParent();
			}
			return currentPath;
		}
		throw new IllegalStateException(
				"This path is not absolute. Use 'Path.isAbsolute()' to test if the path is absolute before calling this method.");
	}

	/**
	 * Returns {@link java.io.File} representation of this file system path.
	 */
	public File toFile() {
		return path;
	}

	/**
	 * Returns {@link URL} representation of this file system path.
	 */
	public URL toURL() {
		File absolutePath = path;
		if (!isAbsolute()) {
			absolutePath = path.getAbsoluteFile();
		}
		try {
			return absolutePath.toURI().toURL();
		} catch (MalformedURLException cannotHappen) {
			return null;
		}
	}

	/**
	 * Returns {@link URI} representation of this file system path.
	 * 
	 * @see java.io.File#toURI()
	 */
	public URI toURI() {
		return path.toURI();
	}

	/**
	 * Returns string representation of this file system path. The resulting
	 * string uses {@link #SEPARATOR} to separate the names in the path.
	 */
	@Override
	public String toString() {
		return path.toString();
	}

	/**
	 * Compares this path with the other path lexicographically. Paths are
	 * compared case-sensitively on UNIX operating systems but case-insensitively
	 * on Microsoft Windows operating systems.
	 * 
	 * @param otherPath
	 *           The path to be compared with
	 * @return zero if this path is equal to the other path, a value less than
	 *         zero if this path is lexicographically less than the other path or
	 *         a value greater than zero if this path is lexicographically
	 *         greater than the other path
	 * @throws IllegalArgumentException
	 *            if other path is {@code null}
	 */
	public int compareTo(Path otherPath) {
		if (otherPath == null) {
			throw new IllegalArgumentException("Other path is null");
		}
		return path.compareTo(otherPath.toFile());
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((path == null) ? 0 : path.hashCode());
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj) {
			return true;
		}
		if (obj == null) {
			return false;
		}
		if (!(obj instanceof Path)) {
			return false;
		}
		Path other = (Path) obj;
		if (path == null) {
			if (other.path != null) {
				return false;
			}
		} else if (!path.equals(other.path)) {
			return false;
		}
		return true;
	}

	final boolean equalsResolved(Path other) {
		try {
			if (path.getCanonicalPath().equals(other.toFile().getCanonicalPath())) {
				return true;
			}
		} catch (IOException e) {
			throw new FileSystemException("Could not compare resolved paths", e);
		}
		return false;
	}
}
